Jerry's Log

Unique Key

contents

1. Unique Key(고유 키)란 무엇인가?

Unique Key는 관계형 데이터베이스 관리 시스템(RDBMS)의 제약 조건(Constraint) 중 하나로, 특정 열(Column)이나 열의 조합에 있는 모든 값이 서로 중복되지 않고 유일하도록 보장합니다. 주 목적은 중복 데이터가 저장되는 것을 방지하여 데이터 무결성(Data Integrity) 을 유지하는 것입니다.

2. 왜 사용하는가?

Primary Key(기본 키)가 행을 식별하는 주민등록번호 같은 역할을 한다면, Unique Key는 "비즈니스 키"나 보조 식별자 역할을 합니다.


3. Unique Key vs. Primary Key (차이점)

가장 많이 혼동되는 부분입니다. 둘 다 유일성을 보장하지만 역할이 다릅니다.

특징 Primary Key (기본 키) Unique Key (고유 키)
목적 행을 유일하게 식별 (개체 무결성) PK가 아닌 열의 중복 방지 (데이터 무결성)
NULL 값 허용 안 함. 절대 NULL일 수 없음. 허용함. 대부분의 DB에서 NULL 허용 (보통 여러 개 가능).
테이블당 개수 테이블당 오직 1개. 테이블당 여러 개 존재 가능.
인덱싱 자동으로 Clustered Index 생성 (보통). 자동으로 Non-Clustered Index 생성.

4. 작동 원리 (내부 구조)

Unique 제약 조건을 적용하면, 데이터베이스 엔진은 해당 열에 대해 Unique Index(고유 인덱스)(보통 B-Tree 구조)를 생성합니다.

  1. 삽입 시 확인: 새로운 행을 INSERT 하려고 할 때, DB는 이 인덱스를 확인합니다.
  2. 검증: B-Tree를 탐색하여 해당 값이 이미 존재하는지 찾습니다.
    • 발견됨: "Unique Constraint Violation(고유 제약 조건 위반)" 오류를 내며 INSERTUPDATE가 실패합니다.
    • 발견 안 됨: 작업이 진행되고, 새 값이 인덱스에 추가됩니다.

5. NULL 값 처리

Unique Key에서 NULL을 처리하는 방식은 DB 시스템마다 다르므로 개발자에게 중요한 디테일입니다.


6. 제약 조건의 종류

A. 단일 열 Unique Key (Single-Column)

제약 조건이 하나의 열에만 적용됩니다.

B. 복합 Unique Key (Composite)

제약 조건이 여러 열의 조합에 적용됩니다. 개별 열의 값은 중복될 수 있지만, 그 **조합(쌍)**은 유일해야 합니다.


7. 문법(Syntax) 예시

테이블 생성 시 Unique Key 추가:

CREATE TABLE Users (
    user_id INT PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100),
    CONSTRAINT UQ_Email UNIQUE (email) -- 제약 조건에 이름을 붙이는 것이 좋습니다.
);

이미 존재하는 테이블에 추가:

ALTER TABLE Users
ADD CONSTRAINT UQ_Username UNIQUE (username);

복합 Unique Key 생성:

CREATE TABLE OrderDetails (
    order_id INT,
    product_id INT,
    quantity INT,
    -- 한 주문 내에서 특정 상품은 한 번만 등장하도록 제한
    CONSTRAINT UQ_Order_Product UNIQUE (order_id, product_id)
);

8. Best Practices (모범 사례)

  1. 제약 조건 명명(Naming): 항상 제약 조건의 이름을 명시하세요 (예: UQ_TableName_ColumnName). 시스템이 임의로 생성한 이름(예: SYS_C001234)을 사용하면 나중에 에러 로그를 보고 디버깅하기 매우 어렵습니다.
  2. 성능(Performance): Unique Key는 인덱스를 생성합니다. 조회(SELECT) 속도는 빨라지지만, 값을 넣을 때마다 인덱스를 검사해야 하므로 INSERTUPDATE 속도는 약간 느려집니다.
  3. Soft Delete(논리적 삭제) 이슈: 데이터를 실제로 지우지 않고 is_deleted 플래그만 변경하는 방식을 쓴다면 Unique Key가 까다로워질 수 있습니다.
    • 문제: 사용자 A가 탈퇴(is_deleted=1)한 후, 나중에 동일한 이메일로 다시 가입하려 하면, DB에 기존(삭제된) 데이터가 남아있어 Unique Key 위반이 발생합니다.
    • 해결: DB가 지원한다면 부분 인덱스(Partial Index, 예: WHERE is_deleted = 0)를 사용해야 합니다.

다음은 부분 인덱스(Partial Index), 또는 필터링된 인덱스(Filtered Index)를 사용하여 "Soft Delete(논리적 삭제)" 충돌 문제를 해결하는 SQL 스크립트입니다.

이 방식은 PostgreSQL, SQL Server, SQLite에서 지원하는 가장 깔끔한 해결책입니다.

시나리오

Users 테이블이 있습니다. 우리는 email이 중복되지 않기를 원하지만, 오직 '활성 사용자(Active User)'끼리만 중복되지 않아야 합니다.

만약 사용자가 "논리적 삭제(is_deleted = true)" 상태라면, Unique 체크에서 무시되어야 하며, 다른 사람이 해당 이메일로 새로 가입할 수 있어야 합니다.

SQL 스크립트 (PostgreSQL / SQL Server 문법)

-- 1. 설정 (SETUP): 테이블 생성
-- 처음에는 email 컬럼에 표준 UNIQUE 제약 조건을 걸지 않습니다.
CREATE TABLE Users (
    user_id INT PRIMARY KEY,
    username VARCHAR(50),
    email VARCHAR(100),
    is_deleted BOOLEAN DEFAULT FALSE -- 0: 활성, 1: 삭제됨
);

-- 2. 해결책 (THE SOLUTION): "부분 고유 인덱스(Partial Unique Index)" 생성
-- 이 인덱스는 is_deleted가 FALSE인 행들에 대해서만 유일성을 강제합니다.
-- 삭제된 행(TRUE)은 이 인덱스에 포함되지 않으므로 검사 대상에서 제외됩니다.
CREATE UNIQUE INDEX UQ_Active_Email 
ON Users (email) 
WHERE is_deleted = FALSE;  -- (SQL Server에서는 'is_deleted = 0' 사용)

-- ==========================================
-- 작동 확인 (DEMONSTRATION)
-- ==========================================

-- 단계 A: 새로운 유저(Bob) 추가
INSERT INTO Users (user_id, username, email, is_deleted)
VALUES (1, 'Bob', 'bob@example.com', FALSE);

-- 결과: 성공 (SUCCESS)
-- 인덱스 확인: 'bob@example.com'이 활성 상태인가? 아니요. -> 삽입 진행.

-- ------------------------------------------

-- 단계 B: 동일한 이메일로 다른 유저 추가 시도
-- INSERT INTO Users (user_id, username, email, is_deleted)
-- VALUES (2, 'FakeBob', 'bob@example.com', FALSE);

-- 결과: 실패 (FAILED)
-- 에러: 중복된 키 값이 "UQ_Active_Email" 고유 제약 조건을 위반했습니다.
-- 이유: 활성 상태(FALSE)인 행이 이미 존재하기 때문입니다.

-- ------------------------------------------

-- 단계 C: 원본 Bob을 "논리적 삭제(Soft Delete)" 처리
UPDATE Users 
SET is_deleted = TRUE 
WHERE user_id = 1;

-- 결과: 성공 (SUCCESS)
-- 이제 Bob은 삭제된 것으로 표시됩니다. 따라서 'UQ_Active_Email' 인덱스에서 빠지게 됩니다.

-- ------------------------------------------

-- 단계 D: Bob이 재가입하거나, 같은 이메일로 다른 사람이 가입 시도
INSERT INTO Users (user_id, username, email, is_deleted)
VALUES (3, 'NewBob', 'bob@example.com', FALSE);

-- 결과: 성공! (SUCCESS!)
-- 이유: 데이터베이스가 'bob@example.com'을 가진 활성 유저를 인덱스에서 찾습니다.
-- 기존 1번 유저는 이제 WHERE 조건(FALSE)에 맞지 않아 인덱스에서 무시됩니다.
-- 따라서 이 이메일은 "사용 가능"한 상태로 간주됩니다.

-- ==========================================
-- 최종 데이터 확인
-- ==========================================
SELECT * FROM Users;

-- 출력 결과:
-- user_id | username | email           | is_deleted
-- 1       | Bob      | bob@example.com | true      (구 계정, 삭제됨)
-- 3       | NewBob   | bob@example.com | false     (신규 계정, 활성)

참고: MySQL의 경우

MySQL 8.0 이전 버전 등에서는 인덱스에 WHERE 절을 사용할 수 없습니다.

MySQL에서의 전략:

단순히 (email, deleted_at)으로 복합 Unique Key를 걸면 문제가 생깁니다. 표준 SQL에서는 NULL끼리 서로 다르다고 판단하기 때문에, deleted_at이 NULL(활성 상태)인 유저가 여러 명 생길 수 있어 유일성 보장이 깨집니다.

이를 해결하기 위해 MySQL에서는 보통 다음과 같은 방법을 씁니다:

  1. Generated Column(생성된 열) 사용 (MySQL 5.7+): active_email이라는 가상 컬럼을 만들어서, 삭제된 경우 NULL, 활성인 경우 email 값을 갖게 한 뒤, 이 컬럼에 Unique Key를 겁니다.
  2. 애플리케이션 처리: DB 제약 조건 대신 서버 코드에서 중복을 체크합니다.

위 스크립트(부분 인덱스) 방식이 모던 데이터베이스 설계의 "모범 사례(Best Practice)"입니다.

references